experiment: Swift-side per-class identity cache with shared memory flag#5
Closed
experiment: Swift-side per-class identity cache with shared memory flag#5
Conversation
…ntity caching Add identityMode: "pointer" option to BridgeJS instantiation. When enabled, a WeakRef-based identity cache keyed by pointer ensures the same Swift heap pointer returns the same JS wrapper (=== equality). Each class gets its own FinalizationRegistry and identity cache stored on the deinit function. Off by default, zero overhead when not enabled.
Replace per-class FinalizationRegistry instances with a single shared registry at module level. Move identity cache from deinit function property to per-class static __identityCache field. Cleaner codegen, fewer allocations, easier to inspect in DevTools.
…ence Each boundary crossing calls passRetained on the Swift side. On cache hit, the wrapper is returned without creating a new FinalizationRegistry entry, leaving the retain unbalanced. Call deinit(pointer) on cache hit to immediately release the extra retain. Also fix deinit reference for namespaced classes to use abiName instead of short class name.
Add Tests/BridgeJSRuntimeTests/IdentityModeSupportTests module covering: - Wrapper identity for shared Swift objects - Cache invalidation on release - Different classes don't collide on same pointer - Retain leak regression test for cache hits - Array identity preservation Wire IDENTITY_MODE env var through prelude.mjs to toggle instantiateOptions. Add unittest-pointer Makefile target for running tests with identityMode: "pointer".
Restore the no-op polyfill pattern for environments without FinalizationRegistry instead of null, matching the upstream convention. Remove finalizer parameter from makeFresh since the polyfill is always callable. Use has() guard before stale WeakRef cleanup. Remove formatting-only changes from instantiate.d.ts.
…ToGlobal pattern Add identityMode field to BridgeJSConfig, flow through SwiftToSkeleton and ExportedSkeleton to BridgeJSLink. Generated JS uses config value as default with runtime option as override via nullish coalescing. Create dedicated BridgeJSIdentityTests target with identityMode: pointer in its bridge-js.config.json. Remove IDENTITY_MODE env var, instantiateOptions spread from prelude.mjs, and unittest-pointer Makefile target. Identity tests now run as part of the normal test suite.
…x.js TypeScript strict excess property check rejects identityMode in the spread into DefaultNodeSetupOptions. Destructure it out before spreading, since it's already handled separately via the instantiateOptions pass-through.
…generate script The Generated files were incorrectly copied from BridgeJSRuntimeTests, containing types from the wrong module. Regenerate with BridgeJSTool for the BridgeJSIdentityTests target. Add target to bridge-js-generate.sh. Fix SwiftToSkeleton formatting.
When multiple targets share one createInstantiator (e.g. test package), use compactMap to find the first non-nil identityMode across all skeletons instead of reading from the first skeleton which may not have it set.
Extend run.js with --identity-mode, --identity-iterations, --identity-reuse-pools, and --identity-memory CLI flags. Extract identity scenarios into lib/identity-benchmarks.js: roundtrip reuse, bulk pool return (100 cached objects), churn (create-roundtrip-release), consume, and create paths. Memory telemetry via --identity-memory. Update README.md with identity mode flags and scenario descriptions.
Add IdentityCacheBenchmark with setupPool/getPoolRepeated for bulk array return scenarios. Update generated BridgeJS bindings for benchmark target.
Benchmark results can be noisy due to GC timing and V8 JIT compilation. IQR filtering discards values outside Q1-1.5*IQR to Q3+1.5*IQR before computing statistics. The Samples column shows retained count (e.g. '4 (-1)' means 4 kept, 1 discarded). Falls back to the full dataset if fewer than 4 samples. Applies to all benchmarks, not just identity mode.
feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching
Add identityMode: Bool parameter to @js macro. When set to true on a class, that class uses pointer identity caching. When false or not set, the class uses the bridge-js.config.json default. Identity is resolved entirely at codegen time - no runtime option. Classes with identity mode get static __identityCache passed to __wrap. Classes without it pass null. No runtime branching in __construct. Resolution: @js(identityMode: true/false) > bridge-js.config.json > default (off).
feat: Add per-class identityMode via @js macro parameter
Replace DataView shared memory flag with @_extern(wasm) JS import for signaling, following the existing BridgeJS intrinsics pattern (_swift_js_push_i32, _swift_js_return_optional_heap_object, etc). Per-class Set tracks exported pointers. On cache hit, thunk calls _swift_js_set_identity_ref(1) and returns passUnretained. JS checks the ref and skips deinit. Trade-off vs JS-only cache: Set.contains with SipHash on WASM adds ~20-30ns per crossing, exceeding the ~4-8ns deinit WASM call it replaces. Improves create-heavy paths by ~20% but regresses roundtrip by ~50%.
444417f to
d632591
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Experimental Swift-side identity cache as an alternative to the JS-side
deinit(pointer)call on cache hits. Builds on top of PRs #2 and #3.Currently, when the same Swift pointer is returned and the JS identity cache hits, JS calls
deinit(pointer)back into WASM to balance thepassRetainedthat Swift did. This PR moves the "already exported?" tracking to Swift, allowing the thunk to skippassRetainedentirely on cache hits.How it works
Each identity-mode class gets a generated
Set<UnsafeMutableRawPointer>tracking exported pointers. The generated thunk checks the Set before returning:Swift signals JS via
@_extern(wasm, module: "bjs", name: "swift_js_set_identity_ref")— the same pattern as_swift_js_push_i32and other existing BridgeJS intrinsics. No DataView, no shared memory buffer management.JS checks the signal before calling
__construct. On cache hit with signal set, JS skipsdeinit. On race condition (stale JS cache), JS callsbjs_identity_retainto recover.The per-class Set and signal are implementation details in the generated code — no changes to user-facing
@JSAPI.What changed
BridgeJSIntrinsics.swift— Added_swift_js_set_identity_refJS import (same pattern as_swift_js_push_i32). Addedbjs_identity_retainWASM export for race recovery. Removed DataView-based shared memory flag.ExportSwift.swift— Identity-mode thunks usewithExtendedLifetime+passUnretained+ Set check. Per-classSet<UnsafeMutableRawPointer>generated alongside thunks. Deinit cleans up Set entry.BridgeJSLink.swift— JS import handler forswift_js_set_identity_ref. Modified__wrapcache hit/miss paths to check signal. Removed DataView infrastructure.Benchmark comparison
Release build, adaptive sampling:
Analysis
The Swift-side approach trades cache-hit speed for create-path improvement.
Set.containswith SipHash on WASM costs ~20-30ns per call (no hardware-accelerated hashing), making the cache-hit path slower than a singledeinitWASM call (~4-8ns after V8 JIT optimization).The
churnObjectsregression is severe — objects are created, crossed, and released in a tight loop. The per-class Set grows because FinalizationRegistry cleanup is asynchronous. This is a known limitation of the per-class Set approach.When this approach wins
When JS-only cache wins